crontab — 定时任务调度详解(含 systemd timer 对比)

crontab(cron table)是 Linux 下最经典的定时任务方案:在配置表里写"某分某时某日执行某命令",由 crond 守护进程每分钟扫描一次,命中即执行。它由 cronie(vixie-cron 的后继实现)提供,每行一个任务,语法极简、人人都会。

本页先按规范梳理 crontab 本身,再单独一章对比 systemd timer,回答"能否直接替代"。

1. 概述

1.1 cron 的三个角色

核心心智模型:cron 只在"系统时间正好匹配"的那一刻执行,它不补跑、不重试。任务若在关机期间错过,就错过了(anacron 只负责目录级每日任务)。这一点与 systemd timer 的 Persistent=yes 完全不同。
专有名词:

1.2 crontab 命令速览

crontab -l                      # 列出当前用户任务表
crontab -e                      # 编辑任务表(保存后自动安装)
crontab -r -i                   # 删除任务表(-i 二次确认)
crontab -T <file>               # 只测语法不安装
crontab -u alice -l             # 管理 alice 的任务表(root 可用)
crontab <file>                  # 用文件内容整体替换任务表
crontab -V                      # 查看版本

2. 行为图(系统"做什么")

2.1 用例图 — cron 交互全景

PlantUML diagram
图 1:crontab 用例图(行为全景)

用例演示

crontab -e                     # 打开编辑器编辑自己的任务
crontab -T ~/mytab             # 测试语法后再安装
sudo crontab -u backup -l      # 查看 backup 用户的任务表
sudo tee /etc/cron.d/myjobs    # 往系统分表写入任务

2.2 活动图 — crond 调度主流程

crond 的生命循环:加载任务表 → 每分钟匹配 → 命中则 fork 子进程执行并处理输出。

PlantUML diagram
图 2:crond 调度主流程活动图(带泳道)

用例演示

# 任务执行后想收到输出邮件
MAILTO=ops@example.com
30 2 * * * /usr/local/bin/backup.sh
# 输出会由 crond 发给 MAILTO;设为 MAILTO="" 则丢弃
专有名词:时间匹配指分/时/月必须全中,且"日"与"星期"至少一中(OR 逻辑,见 4.3);fork 子进程指 crond 克隆出子进程用 /bin/sh -c 执行命令,命令所见环境(SHELL/PATH/HOME/LOGNAME)由 crond 构造。

2.3 状态机图 — cron 任务生命周期

任务表中一条条目的状态流转:从被加载到每次执行、再回到等待下一次触发。

PlantUML diagram
图 3:cron 任务生命周期状态机图

2.4 时序图 — 一次 cron 执行

从用户编辑任务表,到 crond 真正执行命令的完整链路。

PlantUML diagram
图 4:一次 cron 执行时序图
专有名词:inotify 是 Linux 的文件系统变化通知机制,crond 用它实时感知任务表改动(符号链接指向的表除外,需 SIGHUP 重载);安装(install) 指 crontab 命令校验语法后把编辑结果写入 /var/spool/cron/。

3. 结构图(系统"由什么组成")

3.1 部署图 — cron 生态

crond 守护进程、各类任务表、anacron、日志与邮件的协作拓扑。

PlantUML diagram
图 5:cron 生态部署图

用例演示

systemctl status crond              # 查看 cron 服务状态
journalctl -u crond -f              # 跟踪 crond 日志
ls /etc/cron.daily /etc/cron.hourly # 目录级任务脚本
专有名词:/etc/cron.{hourly,daily,weekly,monthly} 是放脚本的目录,由 cron 的 run-parts(或 anacron)在固定时间执行;anacron 会记录上次运行时间,系统开机补跑错过的每日任务。

3.2 组件图 — crond 内部

把 crond 进程拆开:加载、校验、匹配、执行、通知各司其职。

PlantUML diagram
图 6:crond 内部组件图

用例演示

# 权限控制:只允许白名单用户用 crontab
echo "root" > /etc/cron.allow
echo "alice" >> /etc/cron.allow
# 其余用户(含新建用户)一律无法 crontab -e
专有名词:cron.allow / cron.deny 只控制"能否用 crontab 命令",不控制已有任务是否继续执行;两文件都不存在时仅 root 可用。

3.3 包图 — 文件布局

PlantUML diagram
图 7:crontab 文件布局包图
位置类型条目格式用途
/etc/crontab系统主表时间字段 + 用户名 + 命令系统级任务,需 root 编辑
/etc/cron.d/系统分表同上(含用户名)软件包分发自己的任务,如 cron-apt
/var/spool/cron/<user>用户表时间字段 + 命令(无用户名)普通用户经 crontab 命令维护,勿直接编辑
/etc/cron.{hourly,daily,...}/目录级直接放可执行脚本run-parts / anacron 顺序执行

3.4 类图 — 对象模型

PlantUML diagram
图 8:cron 对象模型类图
专有名词:TimeSpec 是"分/时/日/月/星期"五个字段的抽象,支持 * , - / ~ 与名称;CrontabEntry 是"5 个时间字段 + 命令"的最小单位;EnvSetting 是任务表顶部的 KEY=value 行,被同表所有条目继承。

4. 语法规范

crontab 语法是实战最容易出错的点,尤其"日与星期是 OR 逻辑"这一点全网误解率最高。

4.1 时间字段

一条 cron 命令 = 5 个时间字段 + 命令(系统级文件还要加一个用户名):

分  时  日  月  星期   [用户名]  命令
*  *  *  *  *  root    /usr/local/bin/backup.sh
字段取值范围别名
minute(分)0-59—
hour(时)0-23—
day of month(日)1-31—
month(月)1-12jan,feb,...dec(前三个字母,大小写不敏感)
day of week(星期)0-7(0 和 7 都是周日)sun,mon,...sat

命令在"分、时、月"全部匹配当前时间,且 "日"或"星期"至少一个匹配时执行。夏令时跳过的时刻永不执行、重复的时刻执行两次。

4.2 特殊字符与名称

字符含义示例
*任意值(等价"首-末")0 * * * * 每小时整点
,列表1,15 * * * * 每小时第 1、15 分
-范围(含两端)0 9-17 * * * 每天 9-17 点整点
/步进(仅作用于本字段)*/15 * * * * 每 15 分钟
~随机(cronie 扩展,取范围内随机值)6~15 * * * * 每小时的 6-15 分随机一分执行
名称月/星期可用三字母名,可做范围/列表mon,wed,fri、jan-mar
步进陷阱:*/35 不会"每 35 分钟"执行,而是每小时的第 0、35 分钟各执行一次——步进只在字段范围内计算。真正的"每 35 分钟"需用 0-59/35(也是每小时的 0、35 分),要真正按相对间隔执行请考虑 timer 的 OnUnitActiveSec=。

4.3 日(dom) 与 星期(dow) 的 OR 逻辑

当"日"和"星期"两字段都不是 * 时,二者是 OR(任一匹配即执行),这是 cron 与绝大多数人的直觉(AND)不同之处:

30 4 1,15 * 5
# 含义:每月 1 日和 15 日的 4:30,加上每周五的 4:30 —— 而不是"每月1/15日且恰逢周五才执行"
想表达 AND(某日且是某星期)怎么办?cron 语法做不到,需要命令内判断:把某字段留 *,命令里用 test 过滤,如 30 4 1 * * test "$(date +%u)" = 5 && cmd(每月 1 日若恰逢周五才执行)。

4.4 环境变量

任务表顶部可写 KEY=value(等号两侧空格可有可无,值里的多余空格会保留,可用引号包裹):

变量默认说明
SHELL/bin/sh执行命令的 shell,可改为 /bin/bash
PATH随发行版(通常 /usr/bin:/bin)命令找不到时先检查它(最常见坑)
HOME/etc/passwd 中该用户家目录可覆盖
LOGNAME任务属主不可覆盖
MAILTO任务属主命令有输出时发邮件给谁;MAILTO="" 不发
MAILFROM属主用户名邮件发件人(支持 $USER 展开)
CRON_TZ系统时区本任务表使用的时区(表内所有条目按此时区解释)
RANDOM_DELAY无任务随机延迟上限(分钟),错峰用
CONTENT_TYPEtext/plain邮件内容类型编码
环境是精简的:cron 任务的 PATH 通常不含 /usr/local/bin 等目录,且不加载你的 .bashrc。脚本里请用绝对路径,或在任务表里 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。

4.5 特殊字符串

用 @名字 代替前 5 个时间字段(cronie 扩展):

字符串等价写法说明
@reboot—守护进程启动时执行一次(开机时)
@yearly / @annually0 0 1 1 *每年 1 月 1 日 0:00
@monthly0 0 1 * *每月 1 日 0:00
@weekly0 0 * * 0每周日 0:00
@daily / @midnight0 0 * * *每天 0:00
@hourly0 * * * *每小时整点

4.6 % 字符与命令细节

用 % 传标准输入

0 22 * * 1-5 mail -s "提醒" ops@example.com %今晚记得更新周报%第二行内容
# 上面的 %% 输出两个换行, % 后的内容作为邮件正文

4.7 系统级 / 用户级 / 目录级

层级文件谁维护是否含用户名
系统主表/etc/crontabroot是(第 6 字段)
系统分表/etc/cron.d/*root / 软件包是
用户表/var/spool/cron/<user>各用户经 crontab 命令否(命令即以该用户身份运行)
目录级/etc/cron.hourly|daily|weekly|monthly/root 放脚本—(脚本以 root 运行)
# /etc/cron.d/backup — 软件包分发风格(第 6 字段是用户名)
MAILTO=root
30 2 * * *  root  /usr/local/bin/backup.sh

5. timer 与 crontab 对比与迁移

5.1 架构对比

两者最本质的区别:crontab 依赖一个独立的外部守护进程(crond)去轮询时间;systemd timer 是 PID 1 原生内置的调度机制,与服务的 unit 体系(日志、依赖、重启策略)深度耦合。

PlantUML diagram
图 9:crontab 与 systemd timer 架构对比图

5.2 能力对照表

能力crontab(cronie)systemd timer
属主与集成独立 crond 守护进程systemd(PID 1)原生,与服务 unit 一体
最小粒度分钟秒级(日历精确调度,实测亚秒级)
时间表达5 字段(分时日月周)OnCalendar 日历式 + 单调时间(OnBootSec / OnUnitActiveSec)
错过的任务错过即错过(仅目录级由 anacron 补)Persistent=yes 可补跑关机期间错过的触发
相对间隔不支持(*/35 只是整点对齐)OnUnitActiveSec=35min 真正"距上次 N 时间后"循环
随机延迟RANDOM_DELAY(分钟级)RandomizedDelaySec=(秒级)
日志syslog;命令输出发邮件journald,journalctl -u 直接看;输出可重定向到文件/journal
失败处理无重试,靠邮件通知OnFailure=、服务侧 Restart= 联动
依赖与排序无(@reboot 只是开机后执行)After=/Requires=/PartOf=,可与挂载点、网络绑定
用户级任务任何用户 crontab -e(受 allow/deny 控制)systemctl --user + loginctl enable-linger 持久化
语法熟悉度极简、跨发行版通用OnCalendar 需要学习(形如 Mon *-*-* 09:00:00)
管理面crontab 命令 + 手动编辑统一 systemctl:enable/start/status
日与星期OR 逻辑(见 4.3)OnCalendar 多个表达式是 OR,字段内是精确匹配
集群/网络共享支持共享 /var/spool/cron(-n 指定执行节点)无此特性(需自建)

5.3 迁移对照与结论

常见条目迁移对照

crontab 写法systemd timer 的 OnCalendar / 单调时间
*/15 * * * * 每 15 分钟OnCalendar=*:0/15 或 OnUnitActiveSec=15min
0 2 * * * 每天 2:00OnCalendar=*-*-* 02:00:00
0 9-17 * * 1-5 工作日每小时整点OnCalendar=Mon..Fri *-*-* 09:00:00..17:00:00
@dailyOnCalendar=daily
@monthlyOnCalendar=monthly
@rebootOnBootSec=0(开机即触发)
想补跑错过的加 Persistent=yes
随机错峰加 RandomizedDelaySec=5min

一个完整的迁移例子

# 原 crontab
30 2 * * *  root  /usr/local/bin/backup.sh

# 迁移成 timer
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh

# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 2:30

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer

结论:可以直接替代吗?

可以,绝大多数场景建议替代。timer 在精确度、错峰、补跑、日志、失败重试、依赖管理上全面优于 crontab,且与现代发行版(systemd 已是 PID 1)天然一致。但迁移时有四个注意点:

6. 实战速查手册

6.1 crontab 命令

命令说明
crontab -e编辑当前用户任务表(VISUAL/EDITOR 指定编辑器)
crontab -l列出当前用户任务表
crontab -r / -r -i删除任务表(-i 需确认)
crontab -T <file>测试语法不安装
crontab <file> / crontab -用文件 / 标准输入整体替换任务表(脚本友好)
crontab -u user -l查看他人任务表(root)
crontab -V版本

6.2 常用示例

# 每 5 分钟
*/5 * * * *  /opt/scripts/ping-check.sh

# 每小时的第 15 分
15 * * * *   /usr/local/bin/sync-data

# 每天 2:30, 输出追加到日志, 错误也进日志
30 2 * * *   /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

# 每周一至周五 9 点
0 9 * * 1-5  /usr/local/bin/standup-report

# 每月 1 日零点清空临时目录
0 0 1 * *    find /var/tmp -type f -mtime +30 -delete

# 开机执行一次
@reboot      /usr/local/bin/on-boot-tasks.sh

# 用 % 传标准输入给命令
0 22 * * 1-5 mail -s "reminder" ops@x.com %记得备份数据

# 任务表统一配置 PATH / MAILTO
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com
*/10 * * * *  /usr/local/bin/healthcheck

6.3 故障排查

# 1. crond 在跑吗
systemctl status crond
systemctl is-active crond

# 2. 任务有没有被执行(syslog / journal)
journalctl -u crond --since "1 hour ago"
grep CRON /var/log/cron    # 有些发行版写这里

# 3. 语法对不对(先测再装)
crontab -T <file>

# 4. 命令为什么没效果
#    - PATH 里没有该命令 → 任务表里显式设置 PATH 或写绝对路径
#    - 环境变量/家目录不对 → 脚本里 source 需要的配置或用绝对路径
#    - 输出去哪了 → 脚本里把输出写到文件 / 设置 MAILTO 收邮件

# 5. 权限被拒
#    - 检查 /etc/cron.allow 是否把你排除在外
#    - 非 root 不能写 /etc/crontab 与 /etc/cron.d

# 6. 任务表损坏(未以换行结尾)
#    crond 会向 syslog 报 "broken" 警告, 补一个换行再 crontab 命令安装
排障要诀:先确认 crond 在跑,再确认任务表语法(-T),再看 journal 里的 CRON 记录确认是否触发,最后把脚本里 echo 输出重定向到文件定位失败原因。cron 的环境极简,绝对路径是脚本可靠性的第一保证。

6.4 实战 Workshop

一个从零到排障的动手演练,建议在一台测试机/容器上按顺序做。每个练习都是"目标 → 操作 → 验证 → 要点",每个 要点 直接给出结论,不用再自行推测。全程不要用 root 直接 crontab -e(避免污染环境),先备份再改。

开始前的准备:
systemctl status crond            # 确认守护进程在跑(或 service crond status)
which crontab date                # 确认命令存在
mkdir -p ~/cronlab/log ~/cronlab/bin
alias cl=crontab                  # 后面练习简写
crontab -l 2>/dev/null | head     # 查看当前是否已有任务(有则先备份:crontab -l > ~/cronlab/backup.txt)

练习 1:第一个每分钟任务

目标:建立"编辑 → 生效 → 验证 → 删除"的完整闭环。

# 1) 写一个简单脚本
cat > ~/cronlab/bin/tick.sh <<'EOF'
#!/bin/bash
echo "$(date '+%F %T') tick" >> "$HOME/cronlab/log/tick.log"
EOF
chmod +x ~/cronlab/bin/tick.sh

# 2) 生成时间表并安装
printf '* * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/job1.txt
crontab -T ~/cronlab/job1.txt      # 先校验语法
crontab ~/cronlab/job1.txt         # 再安装

# 3) 验证(等 70~130 秒看到至少两条)
sleep 70; cat ~/cronlab/log/tick.log
crontab -l                          # 确认已安装

要点:cron 是非交互环境,路径没有 login shell 的 PATH(默认仅 /usr/bin:/bin),执行时的工作目录也不保证是家目录,所以相对路径 ./cronlab/... 或裸命令名大概率失败。必须用绝对路径;且脚本内部也要用 $HOME 拼绝对路径来定位日志。若脚本里写相对路径,日志可能落到 crond 的工作目录(通常是 /),甚至因目录不存在而写入失败——这是"任务显示执行了却找不到产物"最常见的原因。

练习 2:时间字段五种写法

目标:亲手验证 *、范围、列表、步进、随机、@昵称的语义。

# 分别安装下面的表,每次等 60~130 秒后 cat 日志比对
# A) 每 5 分钟
printf '*/5 * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j2.txt
# B) 每天 8:15(工作日的含义:日与星期 OR)
printf '15 8 * * 1-5 %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j2.txt
# C) 开机一次
printf '@reboot %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j2.txt
crontab -T ~/cronlab/j2.txt && crontab ~/cronlab/j2.txt
crontab -l

要点:*/35 一小时只跑 2 次(第 0 分和第 35 分),一天 48 次——步进只在所属字段内生效,不会跨小时累计,"每 35 分钟一次"必须用 shell 判断(见 6.2 速查表)。5~15 是随机范围,解析表时取一次并固定整个守护进程生命周期,所以 crontab -l 看到的是一次性展开的具体值;用途是让一批节点错峰,避免整点风暴。15 8 * * 1-5 的含义是"每个工作日 8:15"(日与星期是 OR 关系)。

练习 3:% stdin 与日志重定向

目标:体会 % 把命令展开成多行 + 喂 stdin 的机制,以及如何优雅处理输出。

# 1) 用 % 给命令喂 stdin
printf '* * * * * /usr/bin/tee %s/cronlab/log/feed.txt %%hello%%\n' "$HOME" > ~/cronlab/j3.txt
crontab -T ~/cronlab/j3.txt && crontab ~/cronlab/j3.txt
sleep 70; cat ~/cronlab/log/feed.txt      # 应该有两行 hello

# 2) 需要字面 % 时(date 的格式化)必须转义
printf '* * * * * /bin/echo "%%d" >> %s/cronlab/log/pct.log\n' "$HOME" > ~/cronlab/j3b.txt
crontab ~/cronlab/j3b.txt; sleep 70; cat ~/cronlab/log/pct.log

# 3) 关闭邮件噪音:输出重定向到日志文件
printf '* * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j3.txt   # tick.sh 自带 >>

要点:未转义的 % 会被改写成换行,第一个 % 之后的全部内容作为命令的标准输入;需要字面 %(如 date 的 %d)必须写成 \%。生产环境几乎必加 >> 日志 2>&1 并设 MAILTO="" 的原因:默认 cron 会把命令输出打包邮件发给属主,邮件在无人值守的服务器上堆积且 MTA 不一定可用;重定向落盘后,日志集中、可轮转、可 grep 排障,还不依赖发信链路。

练习 4:系统级任务 /etc/cron.d

目标:掌握系统表与用户表的差异(多一个用户名字段)。

# 需 root:在 /etc/cron.d/ 放一个片段
echo "* * * * * root /usr/bin/touch /tmp/system-alive" | sudo tee /etc/cron.d/alive
sleep 70; ls -l /tmp/system-alive

# 故意漏掉用户名字段,观察报错
echo "* * * * * /usr/bin/touch /tmp/bad" | sudo tee /etc/cron.d/bad
sleep 70; grep -iE "bad|errors?" /var/log/cron
# 修复:删掉或补上用户名
sudo rm /etc/cron.d/bad

要点:系统表的语法是"5 个时间字段 + 用户名 + 命令"。用户表没有用户名字段,原样抄进 /etc/cron.d/ 后,crond 会把用户名的位置错误解析——把命令 /usr/bin/touch /tmp/bad 当作用户名、把下一段当命令,整条无法解析导致该表加载失败(syslog 会记 errors)。修复就是补上正确的用户名字段。

练习 5:权限控制 cron.allow / cron.deny

目标:体验白名单/黑名单的生效优先级(allow 存在时 deny 被忽略)。

# 需 root,创建普通测试用户
sudo useradd -m -s /bin/bash cronuser
echo "cronuser" | sudo tee /etc/cron.allow
sudo -u cronuser crontab -l        # 白名单内:正常
sudo crontab -u alice -l           # 不在名单内:报 You (alice) are not allowed
# 验证 allow 优先:即使把 alice 加进 deny,allow 存在时仍可用
echo "alice" | sudo tee -a /etc/cron.deny
# 清理
sudo rm /etc/cron.allow /etc/cron.deny; sudo userdel -r cronuser

要点:allow/deny 的判定:cron.allow 存在则只看它(名单内允许);否则若 cron.deny 存在则排除名单内用户;两者都不存在只有 root 可用。allow 优先于 deny。它们只在 crontab 命令入口做检查,不干预 crond 读取 spool 执行既有任务——所以一个用户被移出白名单后,他已经安装的任务照样跑,除非把对应的时间表删掉(crontab -r)。

练习 6:排障演练

目标:把"任务没跑"的问题现场定位一遍。

# 1) 故意制造 PATH 缺失:命令用裸名
printf '* * * * * nonexistentcmd\n' > ~/cronlab/bad1.txt
crontab ~/cronlab/bad1.txt; sleep 70
grep -i nonexistentcmd /var/log/cron     # 看到 Command not found

# 2) 语法错误排查
printf '99 * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/bad2.txt
crontab -T ~/cronlab/bad2.txt            # 报 minute 越界,未安装

# 3) 末行无换行
printf '*/5 * * * * %s/cronlab/bin/tick.sh' "$HOME" > ~/cronlab/bad3.txt   # 注意结尾没有 \n
crontab ~/cronlab/bad3.txt; sleep 70
grep -i "broken\|missing" /var/log/cron  # 表被判定损坏

# 4) 环境 dump 对比:交互 shell 与 cron 环境的差异
printf '* * * * * env > %s/cronlab/log/cron-env.txt\n' "$HOME" > ~/cronlab/j6.txt
crontab ~/cronlab/j6.txt; sleep 70
diff <(env | sort) <(sort ~/cronlab/log/cron-env.txt) | head -20

要点:三个典型故障都源于"cron 环境 ≠ 交互环境":① 裸命令名找不到是因为默认 PATH=/usr/bin:/bin,交互 shell 里 PATH 被 login 脚本扩展过,cron 里没有——日志出现 Command not found;② 99 越界会被 crontab -T 直接拦截,不会安装;③ 末行无换行会让 crond 判定整表损坏、任务完全不执行(syslog 告警)。第 4 步的 diff 显示 cron 环境缺失大量交互变量(DISPLAY、LS_COLORS、自定义别名/函数等)。这就是为什么生产标准是:命令与脚本内全用绝对路径、表头显式 PATH=、脚本不依赖任何交互环境。

练习 7:自选挑战

综合以上技能完成一个真实小需求(二选一):

  1. 日志轮转:写一个脚本,每天 0:05 把 ~/cronlab/log/tick.log 重命名压缩为 tick.log.YYYYMMDD.gz(只保留最近 7 份)。
  2. 错峰备份:用 RANDOM_DELAY=30 让一批节点上的备份任务随机错开 30 分钟内启动,避免集中打满磁盘。

完成后自查:语法先 crontab -T;命令全绝对路径;输出有日志、无邮件噪音;脚本幂等可重入。

Workshop 小结:练习 1-3 覆盖用户级任务全流程,练习 4 是系统级差异,练习 5 是权限模型,练习 6 把常见故障现场复现,练习 7 是综合交付。全程贯穿三条铁律——先校验后安装、绝对路径、日志落盘。

7. crond 时间调度实现(syscall 视角)

crond 并不是"每秒轮询",而是用系统调用精确睡到下一个分钟边界再醒来调度。这套机制来自 cronie 的 src/cron.c 主循环,理解它就能回答两个常见问题:"为什么 crond 一醒来就知道该跑哪些任务"、"为什么 cron 最小粒度是分钟"。

7.1 三套分钟时钟

cronie 主循环维护三个"分钟时钟"(源码注释原文如此):

while (!got_sigintterm) {
    do {
        cron_sleep(timeRunning + 1, &database);   /* 睡到"下一分钟"那一秒 */
        set_time(FALSE);
    } while (clockTime == timeRunning);            /* 分钟没变就继续睡 */
    timeRunning = clockTime;
    timeDiff = timeRunning - virtualTime;
    load_database(&database);                      /* inotify 或全量重读 */
    ...
    find_jobs(virtualTime, &database, ...);        /* 位图匹配, 命中即入队 */
    job_runqueue();                                /* fork 执行 + 收割 */
}
专有名词:分钟边界(minute boundary)指秒为 0 的整分钟时刻;set_time()读取墙钟并把 clockTime 更新为当前分钟;虚拟时钟(virtualTime)用于在睡过头/时钟跳变时"补跑错过的分钟"。

7.2 syscall 链路时序图

一次"睡到下一分钟 → 醒来调度 → 执行任务"的完整系统调用链路:

PlantUML diagram
图 10:crond 分钟调度 syscall 链路时序图

用例演示

# 用 strace 直接观察 crond 的 nanosleep 与 fork/exec
sudo strace -f -e trace=nanosleep,clock_gettime,fork,execve,wait4 -p $(pgrep crond) -o /tmp/crond.strace
sleep 70   # 跨过一个分钟边界后看 /tmp/crond.strace
# 可看到: clock_gettime → nanosleep({60}) → ... → fork → execve(/bin/sh -c ...) → wait4

7.3 syscall 清单

环节系统调用作用
读时钟time() / gettimeofday()取墙钟秒/微秒,换算当前分钟;现代 Linux 走 vDSO(内核 clock_gettime)不陷入内核
等待分钟边界sleep() → nanosleep()阻塞到目标秒数,内核用 hrtimer 高精度定时器实现,不是轮询、不占 CPU
信号打断rt_sigaction / rt_sigreturn注册 SIGHUP/SIGCHLD/SIGURG/SIGTERM;任一信号可打断 nanosleep,之后重算剩余秒数继续睡
拉起任务fork() + execve()命中任务由 job_runqueue() 以 /bin/sh -c 拉起子进程执行
回收子进程waitpid(WNOHANG)sigchld_reaper 在 SIGCHLD 后收割退出的任务子进程,避免僵尸
专有名词:vDSO(虚拟动态共享对象)把 clock_gettime/gettimeofday 等只读时钟调用映射到用户态,省去系统调用陷入;hrtimer(高精度定时器)是内核的定时唤醒机制,nanosleep 依赖它;EINTR表示睡眠被信号打断。

7.4 虚拟时钟追赶与 DST

醒来后计算 timeDiff = timeRunning - virtualTime,按差值分四种情况处理:

timeDiff场景处理
1最常见,正常准点醒来快速路径,直接 find_jobs 调度当前分钟
small(晚醒几分钟)系统繁忙或 sleep 被打断virtualTime++ 逐分钟补跑错过的任务
medium(多了一小时)DST 开始 / 时钟被调快通配任务跑一次,固定时刻任务逐个补跑
negative(少了一小时)DST 结束 / 时钟被调慢只跑通配任务,固定任务不重复执行
large(大幅跳变)时钟被大幅修改把 virtualTime 直接跳到当前,全量跑一次
专有名词:DST(夏令时)切换造成的"缺一小时/多一小时":缺失的小时永不匹配、重复的小时匹配两次(这正是 crond 用虚拟时钟追赶的原因);通配任务(wildcard job)指 minute/hour 字段含 * 的任务。

7.5 位图匹配与分钟粒度

每个任务条目的时间字段在解析时被展开成位图(bitfield):minute 60 位、hour 24 位、month 12 位、dom/dow 31/8 位。调度时把当前时间的五个分量拿出来逐位测试,全部命中才执行:

if (bit_test(e->minute, minute) && bit_test(e->hour, hour) &&
    bit_test(e->month, month) &&
    (((e->flags & DOM_STAR) || (e->flags & DOW_STAR))
        ? (bit_test(e->dow, dow) && bit_test(e->dom, dom))
        : (bit_test(e->dow, dow) || bit_test(e->dom, dom))))
    job_add(e, u);   /* 命中即入队,job_add 保证同一分钟不重复入队 */
为什么最小粒度是分钟:minute 字段就是一张 60 位的位图,crond 只在分钟边界醒来一次,天然无法表达秒级/亚秒级任务。这也是 5.2 里 systemd timer 能精确到秒而 cron 不能的根因——要秒级调度,请用 timer 的 OnCalendar=*-*-* *:*:00。
专有名词:bit_test指"判断该位是否为 1"的位运算;DOM_STAR / DOW_STAR是"日/星期字段为 * "的标记,专门实现"两字段都是 * 则 AND、只要有一个是 * 或都限定则 OR"的边界语义(cronie 源码自嘲 it's bizarre, but it's the standard)。

7.6 hrtimer 原理(高精度定时器)

7.2 里说 nanosleep 靠"内核 hrtimer"实现。hrtimer(high-resolution timer)是 Linux 内核独立于 jiffies 定时轮(timer wheel)的高精度定时器子系统——crond 睡得准不准,取决于它。

为什么需要两套定时器子系统

数据结构:红黑树 + pending 链表

hrtimer 用红黑树(rb-tree)按"到期时间"有序存放定时器,另配一条独立的 pending 链表供到期处理快速取用,无需遍历整棵树。每 CPU 一个实例,且按时钟类型分为多个 clock base(CLOCK_MONOTONIC、CLOCK_REALTIME、CLOCK_BOOTTIME 等),到期时间存绝对时间——因此 settimeofday/NTP 校时无需改动红黑树,天然解决旧实现"全量摘出再重插"的难题。

ktime_t          // 64 位纳秒时间类型
hrtimer_init(&t, CLOCK_MONOTONIC, HRTIMER_MODE_ABS);
hrtimer_start(&t, expiry, HRTIMER_MODE_ABS);   /* 按到期时间插入红黑树 */

到期机制:只关心"最早到期"

调度器每次只需看红黑树的最左节点(最早到期的定时器),通过 clockevents 把这个时刻编程进硬件定时器(见 7.7);到点时硬件产生中断,进入 hrtimer_interrupt() 弹出所有到期节点、执行回调、唤醒对应任务。没有到期任务时 CPU 进入空闲(hlt/mwait),靠这个硬件中断把自己叫醒。

PlantUML diagram
图 11:nanosleep → hrtimer → 硬件定时器 → 中断唤醒 完整链路时序图

用例演示

# 看 crond 到底睡在哪个 hrtimer clock base(ftrace 观测)
sudo trace-cmd record -e timer:hrtimer_start -e timer:hrtimer_expire_entry sleep 70
trace-cmd report | grep -E "hrtimer_(start|expire)" | head

# 确认系统高精度定时是否开启
cat /sys/kernel/debug/clock/boot_clock/resolution    # 通常显示 1(纳秒级)
专有名词:timer wheel(定时轮)按 jiffies 分桶的旧定时器,级联时有 O(N) 开销;ktime_t64 位纳秒时间,hrtimer 的唯一时间单位;clock base同一时钟源(如 CLOCK_MONOTONIC)的定时器集合,每 CPU 一组;clockevents把"下一个到期时刻"下发给硬件定时器的内核抽象。

7.7 硬件定时器与 ISA 级控制

hrtimer 只是软件侧的时间组织,真正"到点叫醒 CPU"的是硬件定时器 + 中断投递。在 x86 上,内核通过三类 ISA(指令集体系结构)手段控制它们:端口 I/O 指令、MSR 读写指令、MMIO 访存。理解这条链路,"CPU 为什么恰好在一分钟后醒来"就有了最终答案。

x86 常见定时硬件与编程接口

硬件定时器ISA 编程接口(指令)特点
PIT 8254(可编程间隔定时器)端口 I/O:in/out 访问 0x40-0x43,写模式字 + 计数值最古老,周期/单次,精度低,传统 IRQ0 源
本地 APIC 定时器(Local APIC Timer)rdmsr/wrmsr 读写 IA32_APIC_BASE(MSR 0x1B)定位 APIC 页,再经 MMIO 写 LVT 定时器寄存器(偏移 0x320)与初始计数(0x380)现代首选:每 CPU 一个、微秒级、周期/单次、可设为 boot 时钟
HPET(高精度事件定时器)MMIO:主计数器 + 各比较器寄存器,经 PCI 枚举映射到内存地址空间周期定时,多个比较器,可路由中断
TSC(时间戳计数器)rdtsc / rdtscp 指令直接读 64 位周期计数只用于读时间,不产生中断,常作 clocksource
IO-APICMMIO(I/O 中断路由表)把外部设备/PIT/HPET 的中断路由到各 CPU
PlantUML diagram
图 12:x86 硬件定时器拓扑与 ISA 编程接口部署图

中断投递的 ISA 链路(以本地 APIC 定时器为例)

  1. 内核把"下一分钟边界"换算成周期数,wrmsr/MMIO 写入 LVT 定时器寄存器与初始计数;
  2. 硬件开始倒计数,计数到 0 时本地 APIC 产生一条定时器中断;
  3. 本地 APIC 把中断按向量号投递给 CPU,CPU 依据 lidt 装载的 IDT(中断描述符表)找到入口,跳到 hrtimer_interrupt()(此时 IF 标志若被 cli 清零则屏蔽,中断挂起等 sti);
  4. 处理完到期回调后 iret 返回,被唤醒的任务(crond)继续执行。

idle 与"谁控制指令流"

没有定时任务时,CPU 执行 hlt(或 monitor/mwait)停止指令流进入低功耗空闲;到点的定时器中断是重新"夺取控制权"的信号——这正是"ISA 如何控制"的落点:用 hlt 主动让出执行,用 IDT 让硬件事件把指令流拉回来。crond 的整条链路因此是:nanosleep → hrtimer 红黑树 → clockevents → 本地 APIC 定时器(wrmsr/MMIO)→ 倒计时中断 → IDT → hrtimer_interrupt → 唤醒 crond → 7.1~7.5 的分钟匹配调度。

# 观察 crond 睡眠时 CPU 是否真的 halt(systat 的 mpstat %idle,或 powertop)
sudo powertop --html=/tmp/power.html
# 查看当前用的 clocksource
cat /sys/devices/system/clocksource/clocksource0/current_clocksource   # 通常 tsc
专有名词:ISA(指令集体系结构)指 CPU 暴露的指令级控制面:端口 I/O(in/out)、MSR(rdmsr/wrmsr)、MMIO、中断指令(int/iret)、特权位控制(cli/sti)、空闲指令(hlt/mwait);MSR(模型专属寄存器)须 CPL 0 且用 wrmsr 写;LVT(Local Vector Table)本地 APIC 的中断向量寄存器组;IDT由 lidt 装载,中断向量在此查表进入处理函数;IF(中断允许标志)由 sti/cli 置/清,清零时屏蔽可屏蔽中断。